Vá além dos testes tradicionais baseados em exemplos. Este guia completo explora testes baseados em propriedades em JavaScript usando fast-check, ajudando a encontrar mais bugs com menos código.
Além dos Exemplos: Um Mergulho Profundo nos Testes Baseados em Propriedades em JavaScript
Como desenvolvedores de software, passamos uma quantidade significativa de tempo a escrever testes. Criamos meticulosamente testes unitários, testes de integração e testes ponta a ponta para garantir que as nossas aplicações são robustas, fiáveis e livres de regressões. O paradigma dominante para isto são os testes baseados em exemplos. Pensamos numa entrada específica e afirmamos uma saída específica. A entrada `[1, 2, 3]` deve produzir a saída `6`. A entrada `"hello"` deve tornar-se `"HELLO"`. Mas esta abordagem tem uma fraqueza silenciosa e à espreita: a nossa própria imaginação.
E se se esquecer de testar com um array vazio? Um número negativo? Uma string contendo caracteres Unicode? Um objeto profundamente aninhado? Cada caso extremo esquecido é um potencial bug à espera de acontecer. É aqui que os Testes Baseados em Propriedades (PBT) entram em cena, oferecendo uma poderosa mudança de paradigma que nos ajuda a construir software mais confiante e resiliente.
Este guia abrangente irá guiá-lo pelo mundo dos testes baseados em propriedades em JavaScript. Vamos explorar o que são, por que são tão eficazes e como pode implementá-los nos seus projetos hoje usando a popular biblioteca `fast-check`.
As Limitações dos Testes Tradicionais Baseados em Exemplos
Vamos considerar uma função simples que ordena um array de números. Usando um framework popular como Jest ou Vitest, o nosso teste poderia parecer-se com isto:
// A simple (and slightly naive) sort function
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
// A typical example-based test
test('sortNumbers should correctly sort a simple array', () => {
const inputArray = [3, 1, 4, 1, 5, 9];
const expectedArray = [1, 1, 3, 4, 5, 9];
expect(sortNumbers(inputArray)).toEqual(expectedArray);
});
Este teste passa. Poderíamos adicionar mais alguns blocos `it` ou `test`:
- Um array que já está ordenado.
- Um array com números negativos.
- Um array com um zero.
- Um array vazio.
- Um array com números duplicados (que já cobrimos).
Sentimo-nos bem. Cobrimos o básico. Mas o que nos escapou? E quanto a `[-0, 0]`? E `[Infinity, -Infinity]`? E um array muito grande que poderia atingir limites de desempenho ou otimizações estranhas do motor JavaScript? O problema fundamental é que nós estamos selecionando os dados manualmente. Os nossos testes são tão bons quanto os exemplos que conseguimos conceber, e os humanos são notoriamente maus a imaginar todas as formas estranhas e maravilhosas como os dados podem ser estruturados.
Os testes baseados em exemplos validam que o seu código funciona para alguns cenários escolhidos a dedo. Os testes baseados em propriedades validam que o seu código funciona para classes inteiras de entradas.
O que são Testes Baseados em Propriedades? Uma Mudança de Paradigma
Os testes baseados em propriedades viram o jogo. Em vez de afirmar que uma entrada específica produz uma saída específica, você define uma propriedade geral do seu código que deve ser verdadeira para qualquer entrada válida. O framework de testes gera então centenas ou milhares de entradas aleatórias para tentar provar que a sua propriedade está errada.
Uma "propriedade" é uma invariante — uma regra de alto nível sobre o comportamento da sua função. Para a nossa função `sortNumbers`, algumas propriedades poderiam ser:
- Idempotência: Ordenar um array já ordenado não deve alterá-lo. `sortNumbers(sortNumbers(arr))` deve ser o mesmo que `sortNumbers(arr)`.
- Invariância do Comprimento: O array ordenado deve ter o mesmo comprimento que o array original.
- Invariância do Conteúdo: O array ordenado deve conter exatamente os mesmos elementos que o array original, apenas numa ordem diferente.
- Ordem: Para quaisquer dois elementos adjacentes no array ordenado, `sorted[i] <= sorted[i+1]`.
Esta abordagem leva-o de pensar em exemplos individuais para pensar sobre o contrato fundamental do seu código. Esta mudança de mentalidade é incrivelmente valiosa para projetar APIs melhores e mais previsíveis.
Os Componentes Principais do PBT
Um framework de testes baseados em propriedades tem tipicamente dois componentes chave:
- Geradores (ou Arbitraries): Estes são responsáveis por produzir uma vasta gama de dados aleatórios de acordo com tipos especificados (inteiros, strings, arrays de objetos, etc.). Eles são suficientemente inteligentes para gerar não apenas dados de "caminho feliz", mas também casos extremos complicados como strings vazias, `NaN`, `Infinity`, e mais.
- Encolhimento (Shrinking): Este é o ingrediente mágico. Quando o framework encontra uma entrada que falsifica a sua propriedade (ou seja, causa uma falha no teste), ele não reporta apenas a entrada grande e aleatória. Em vez disso, tenta sistematicamente encontrar a menor e mais simples entrada que ainda causa a falha. Isto torna a depuração exponencialmente mais fácil.
Primeiros Passos: Implementando PBT com `fast-check`
Embora existam várias bibliotecas de PBT no ecossistema JavaScript, `fast-check` é uma escolha madura, poderosa e bem mantida. Integra-se perfeitamente com frameworks de teste populares como Jest, Vitest, Mocha e Jasmine.
Instalação e Configuração
Primeiro, adicione `fast-check` às dependências de desenvolvimento do seu projeto. Vamos assumir que está a usar um executor de testes como o Jest.
npm install --save-dev fast-check jest
# or
yarn add --dev fast-check jest
# or
pnpm add -D fast-check jest
Seu Primeiro Teste Baseado em Propriedades
Vamos reescrever o nosso teste `sortNumbers` usando `fast-check`. Testaremos a propriedade de "ordem" que definimos anteriormente: cada elemento deve ser menor ou igual ao que o segue.
import * as fc from 'fast-check';
// The same function from before
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
test('the output of sortNumbers should be a sorted array', () => {
// 1. Describe the property
fc.assert(
// 2. Define the arbitraries (input generators)
fc.property(fc.array(fc.integer()), (data) => {
// `data` is a randomly generated array of integers
const sorted = sortNumbers(data);
// 3. Define the predicate (the property to check)
for (let i = 0; i < sorted.length - 1; ++i) {
if (sorted[i] > sorted[i + 1]) {
return false; // The property is falsified
}
}
return true; // The property holds for this input
})
);
});
test('sorting should not change the array length', () => {
fc.assert(
fc.property(fc.array(fc.float()), (data) => {
const sorted = sortNumbers(data);
return sorted.length === data.length;
})
);
});
Vamos analisar isto:
- `fc.assert()`: Este é o executor. Ele executará a sua verificação de propriedade muitas vezes (100 por defeito).
- `fc.property()`: Isto define a propriedade em si. Leva um ou mais arbitraries como argumentos, seguido por uma função predicado.
- `fc.array(fc.integer())`: Este é o nosso arbitrary. Diz ao `fast-check` para gerar um array (`fc.array`) de inteiros (`fc.integer()`). O `fast-check` irá gerar automaticamente arrays de diferentes comprimentos, com diferentes valores inteiros (positivos, negativos, zero, etc.).
- O Predicado: A função anónima `(data) => { ... }` é onde a nossa lógica reside. Ela recebe os dados gerados aleatoriamente e deve retornar `true` se a propriedade se mantiver ou `false` se for violada. O `fast-check` também suporta funções predicado que lançam um erro em caso de falha, o que se integra bem com as asserções `expect` do Jest.
Agora, em vez de um teste com um array escolhido a dedo, temos um teste que verifica a nossa lógica de ordenação contra 100 arrays diferentes, gerados automaticamente, cada vez que executamos a nossa suite. Aumentámos massivamente a nossa cobertura de testes com apenas algumas linhas de código.
Explorando Arbitraries: Gerando os Dados Corretos
O poder do PBT reside na sua capacidade de gerar dados diversos e desafiadores. O `fast-check` fornece um rico conjunto de arbitraries para cobrir quase qualquer estrutura de dados que possa imaginar.
Arbitraries Básicos
Estes são os blocos de construção para a sua geração de dados.
- `fc.integer()`, `fc.float()`, `fc.bigInt()`: Para números. Podem ser restringidos, ex: `fc.integer({ min: 0, max: 100 })`.
- `fc.string()`, `fc.asciiString()`, `fc.unicodeString()`: Para strings de vários conjuntos de caracteres.
- `fc.boolean()`: Para `true` ou `false`.
- `fc.constant(value)`: Retorna sempre o mesmo valor. Útil para misturar com `fc.oneof`.
- `fc.constantFrom(val1, val2, ...)`: Retorna um dos valores constantes fornecidos.
Arbitraries Complexos e Compostos
Pode combinar arbitraries básicos para criar estruturas de dados complexas.
- `fc.array(arbitrary, constraints)`: Gera um array de elementos criados pelo arbitrary fornecido. Pode restringir o `minLength` e `maxLength`.
- `fc.tuple(arb1, arb2, ...)`: Gera um array de comprimento fixo onde cada elemento tem um tipo específico e diferente.
- `fc.object(shape)`: Gera objetos com uma estrutura definida. Exemplo: `fc.object({ id: fc.uuidV(4), name: fc.string() })`.
- `fc.oneof(arb1, arb2, ...)`: Gera um valor de qualquer um dos arbitraries fornecidos. Isto é excelente para testar funções que lidam com múltiplos tipos de dados (ex: `string | number`).
- `fc.record({ key: arb, value: arb })`: Gera objetos para serem usados como dicionários ou mapas, onde chaves e valores são gerados a partir de arbitraries.
Criando Arbitraries Personalizados com `map` e `chain`
Às vezes, precisa de dados que não se encaixam numa forma padrão. O `fast-check` permite-lhe criar os seus próprios arbitraries transformando os existentes.
Usando `.map()`
O método `.map()` transforma a saída de um arbitrary noutra coisa. Por exemplo, vamos criar um arbitrary que gera strings não vazias.
const nonEmptyStringArb = fc.string({ minLength: 1 });
// Or, by transforming an array of characters
const nonAStringArb = fc.array(fc.char().filter(c => c !== 'a'))
.map(chars => chars.join(''));
Usando `.chain()`
O método `.chain()` é mais poderoso. Permite-lhe criar um novo arbitrary com base no valor gerado de um anterior. Isto é essencial para criar dados correlacionados.
Imagine que precisa de gerar um array e depois um índice válido para esse mesmo array. Não pode fazer isto com dois arbitraries separados, pois o índice pode estar fora dos limites. O `.chain()` resolve isto perfeitamente.
// Generate an array and a valid index into it
const arrayAndValidIndexArb = fc.array(fc.anything()).chain(arr => {
// Based on the generated array `arr`, create a new arbitrary for the index
const indexArb = fc.integer({ min: 0, max: arr.length - 1 });
// Return a tuple of the array and the generated index
return fc.tuple(fc.constant(arr), indexArb);
});
// Usage in a test
test('slicing at a valid index should work', () => {
fc.assert(
fc.property(arrayAndValidIndexArb, ([arr, index]) => {
// Both `arr` and `index` are guaranteed to be compatible
const sliced = arr.slice(0, index);
expect(sliced.length).toBe(index);
})
);
});
O Poder do Encolhimento (Shrinking): Depuração Facilitada
A característica mais convincente dos testes baseados em propriedades é o encolhimento (shrinking). Para vê-lo em ação, vamos criar uma função deliberadamente com erros.
// This function fails if the input array contains the number 42
function sumWithoutBug(arr) {
if (arr.includes(42)) {
throw new Error('This number is not allowed!');
}
return arr.reduce((acc, val) => acc + val, 0);
}
test('sumWithoutBug should sum numbers', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
sumWithoutBug(data);
})
);
});
Quando executa este teste, o `fast-check` irá quase certamente encontrar um caso de falha. Mas não irá reportar o primeiro array aleatório que encontrou, que poderia ser algo como `[-1024, 500, 42, 987, -2000]`. Um relatório de falha como esse não é muito útil. Teria de o inspecionar manualmente para encontrar o problemático `42`.
Em vez disso, o encolhedor do `fast-check` entrará em ação. Ele verá a falha e começará a simplificar a entrada:
- Posso remover um elemento? Tenta `[500, 42, 987, -2000]`. Ainda falha. Bom.
- Posso remover outro? Tenta `[42, 987, -2000]`. Ainda falha.
- ...e assim por diante, até não conseguir remover mais elementos sem fazer o teste passar.
- Também tentará tornar os números mais pequenos. `42` pode ser `0`? Não, o teste passa. Pode ser `41`? O teste passa. Ele vai afunilando.
O relatório de erro final será algo como isto:
Error: Property failed after 15 tests
{ seed: 12345678, path: "14", endOnFailure: true }
Counterexample: [[42]]
Shrunk 5 time(s)
Got error: This number is not allowed!
Ele diz-lhe a entrada exata e mínima que causou a falha: um array contendo apenas o número `[42]`. Isto aponta imediatamente para a origem do bug, poupando-lhe imenso tempo e esforço na depuração.
Estratégias Práticas de PBT e Exemplos do Mundo Real
O PBT não é apenas para funções matemáticas. É uma ferramenta versátil que pode ser aplicada a muitas áreas do desenvolvimento de software.
Propriedade: Funções Inversas
Se tem uma função que codifica dados e outra que os descodifica, elas são inversas uma da outra. Uma ótima propriedade a testar é que descodificar um valor codificado deve sempre retornar o valor original.
// `encode` and `decode` could be for base64, URI components, or custom serialization
function encode(obj) { return JSON.stringify(obj); }
function decode(str) { return JSON.parse(str); }
test('decode(encode(x)) should be equal to x', () => {
// `fc.jsonValue()` generates any valid JSON value: strings, numbers, objects, arrays
fc.assert(
fc.property(fc.jsonValue(), (originalValue) => {
const encoded = encode(originalValue);
const decoded = decode(encoded);
expect(decoded).toEqual(originalValue);
})
);
});
Propriedade: Idempotência
Uma operação é idempotente se aplicá-la várias vezes tem o mesmo efeito que aplicá-la uma vez. `f(f(x)) === f(x)`. Esta é uma propriedade crucial para coisas como funções de limpeza de dados ou endpoints `DELETE` numa API REST.
// A function that removes leading/trailing whitespace and collapses multiple spaces
function normalizeWhitespace(text) {
return text.trim().replace(/\s+/g, ' ');
}
test('normalizeWhitespace should be idempotent', () => {
fc.assert(
fc.property(fc.string(), (originalString) => {
const once = normalizeWhitespace(originalString);
const twice = normalizeWhitespace(once);
expect(twice).toBe(once);
})
);
});
Propriedade: Testes de Estado (Baseados em Modelo)
Esta é uma técnica mais avançada, mas incrivelmente poderosa para testar sistemas com estado interno, como um componente de UI, um carrinho de compras ou uma máquina de estados. A ideia é criar um modelo de software simples do seu sistema e uma série de comandos que podem ser executados tanto no seu modelo quanto na implementação real. A propriedade é que o estado do modelo e o estado do sistema real devem corresponder sempre.
O `fast-check` fornece `fc.commands` para este propósito. Vamos modelar um contador simples:
// The real implementation
class Counter {
constructor() { this.count = 0; }
increment() { this.count++; }
decrement() { this.count--; }
get() { return this.count; }
}
// The commands for fast-check
const incrementCmd = fc.command(
// check: a function to check if the command can be run on the model
(model) => true,
// run: a function to execute the command on both model and real system
(model, real) => {
model.count++;
real.increment();
expect(real.get()).toBe(model.count);
}
);
const decrementCmd = fc.command(
(model) => true,
(model, real) => {
model.count--;
real.decrement();
expect(real.get()).toBe(model.count);
}
);
test('Counter should behave according to the model', () => {
fc.assert(
fc.property(fc.commands([incrementCmd, decrementCmd]), (cmds) => {
const model = { count: 0 };
const real = new Counter();
fc.modelRun(() => ({ model, real }), cmds);
})
);
});
Neste teste, o `fast-check` irá gerar uma sequência aleatória de comandos `increment` e `decrement`, executá-los tanto no nosso modelo de objeto simples quanto na classe `Counter` real, e garantir que eles nunca divergem. Isto pode descobrir bugs subtis em lógicas de estado complexas que seriam quase impossíveis de encontrar com testes baseados em exemplos.
Quando NÃO Usar Testes Baseados em Propriedades
O PBT é uma adição poderosa ao seu conjunto de ferramentas de teste, mas não substitui todas as outras formas de teste. Não é uma bala de prata.
Testes baseados em exemplos são geralmente melhores quando:
- Testar regras de negócio específicas e conhecidas. Se um cálculo de imposto deve produzir exatamente `$10.53` para uma entrada específica, um simples teste baseado em exemplo é mais claro e direto. Este é um teste de regressão para um requisito conhecido.
- A "propriedade" é apenas "a entrada X produz a saída Y". Se não há uma regra generalizável de nível superior sobre o comportamento da função, forçar um teste baseado em propriedade pode ser mais complexo do que vale a pena.
- Testar interfaces de utilizador para correção visual. Embora possa testar a lógica de estado de um componente de UI com PBT, verificar um layout ou estilo visual específico é melhor tratado por testes de snapshot ou ferramentas de regressão visual.
A estratégia mais eficaz é uma abordagem híbrida. Use testes baseados em propriedades para testar exaustivamente os seus algoritmos, transformações de dados e lógica de estado contra um universo de possibilidades. Use testes tradicionais baseados em exemplos para fixar requisitos de negócio específicos e críticos e prevenir regressões em bugs conhecidos.
Conclusão: Pense em Propriedades, Não Apenas em Exemplos
Os testes baseados em propriedades incentivam uma mudança profunda na forma como pensamos sobre a correção. Forçam-nos a recuar dos exemplos individuais e a considerar os princípios e contratos fundamentais que o nosso código deve cumprir. Ao fazer isso, podemos:
- Descobrir casos extremos surpreendentes para os quais nunca teríamos pensado em escrever testes.
- Ganhar muito mais confiança na robustez do nosso código.
- Escrever testes mais expressivos que documentam o comportamento do nosso sistema em vez de apenas a sua saída para algumas entradas.
- Reduzir drasticamente o tempo de depuração graças ao poder do encolhimento (shrinking).
Adotar testes baseados em propriedades pode parecer estranho no início, mas o investimento vale bem a pena. Comece pequeno. Escolha uma função pura na sua base de código — uma que lida com transformação de dados ou um cálculo complexo — e tente definir uma propriedade para ela. Adicione um teste baseado em propriedade ao seu próximo projeto. Quando testemunhar o seu primeiro bug não trivial a ser encontrado, ficará convencido do seu poder para construir software melhor e mais fiável para uma audiência global.
Recursos Adicionais
- Documentação Oficial do fast-check
- Entendendo Testes Baseados em Propriedades por Scott Wlaschin (uma introdução clássica e agnóstica em termos de linguagem)